Make issue close safe to retry with an idempotency key - #332
Conversation
…-safety # Conflicts: # internal/db/errors.go # internal/db/pgstore/stubgen/main_test.go
roborev: Combined Review (
|
roborev: Combined Review (
|
|
looking |
A keyed close retry only needs the committed issue.closed receipt, which the events table already stores under the idempotency key. Schema version 27 and the close_event_deliveries table existed to re-broadcast the event batch after a daemon crashed between commit and publish. That gap exists for every mutation today because the hook queue is in-memory, and SSE clients already recover missed events through Last-Event-ID, so a per-mutation delivery table and claim lease bought little for its cost. The table also carried two defects: a key reused after the seven-day lookup window collided with its permanent primary key and returned a 500, and the PostgreSQL table registry never listed it, so split-role privilege validation skipped it. The ambiguous-commit case still publishes exactly once. Both stores return the attempted event batch alongside a commit error, and the handler publishes that batch only when the receipt it finds is the same event this attempt wrote. Replayed receipts now also check the caller's host scope against the issue's current project, matching the comment replay path, so a moved issue cannot leak state through the old route. Generated with Claude Code Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
roborev: Combined Review (
|
A comment idempotency key now identifies one request to one issue. Before this, a short-id request looked the key up by project while a ULID request looked it up by issue, so the same key sent to a second issue in the same project with the same body replayed the first issue's comment instead of posting one, and the two ref forms took different locks for the same retry. Every keyed comment now resolves to an issue UID first and locks, looks up, and fingerprints under that UID. A retry with a full ULID still survives a project move because it needs no route-scoped resolution. Replaying a close or comment receipt exposes the issue's current state, so the replay paths now apply the same visibility rules as a fresh request: the route must be the project the receipt was written in or the project the issue lives in now, that current project must not be archived, and it must be inside the caller's host scope. Without this, a receipt could be read through an unrelated project route or after its project was archived. Generated with Claude Code Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
roborev: Combined Review (
|
A keyed comment retry that names the issue by short ID resolves inside the route project. After the issue moves, that resolution fails and the retry returned 404, so the caller could not recover its receipt and might post the comment again in the new project. The receipt written in the route project still names the issue, so the handler now uses it to find the issue UID when the short ID is a suffix of that UID and any qualifier names the route project. The locked, issue-scoped lookup then replays as usual. A key alone cannot steer a retry to a different issue. A full-ULID retry skips ordinary resolution, so it replayed the comment and issue after the issue was soft-deleted. Replay now returns the same not-found response a fresh comment on a deleted issue gets. Generated with Claude Code Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
roborev: Combined Review (
|
|
Accepted as is |
Closing an issue can now be retried safely after a lost response. Send
Idempotency-Keyon the close request (kata close --idempotency-key, MCPidempotency_key) and a retry returns the original close instead of running a second one.If-Match(kata close --if-match, MCPrevision) rejects a close of an issue that changed underneath you.Before this, a client that timed out after the daemon committed had no safe move. Retrying gave a no-op with no event, and the CLI's follow-up
--commentcould post twice.What a retry gets:
retry_protocol: "close-v1"in the body. Old daemons reject the unknown field instead of silently ignoring the headers. API schema version is 0.15.0.No schema change. Hook delivery stays in memory, so a daemon crash between commit and publish loses hook jobs for a close the same way it does for every other mutation.
Also: a comment idempotency key now belongs to one issue, not one project, and a comment retry survives a project move.